spb/svgarden Public
SVGarden — searchable bank of 74 self-contained SVG+CSS animation snippets (svgarden.dev)
HTML 79.2%
Astro 10.3%
JavaScript 6%
CSS 3.7%
Shell 0.8%
1---2/**3 * ============================================================4 * SVGarden — https://www.svgarden.dev5 * Author : Simon-Pierre Boucher6 * Contact: contact@spboucher.ai7 * File : src/pages/snippet/[slug].astro8 * Desc : Snippet detail — live preview, customizer, lesson, copy-ready code9 * ============================================================10 */11import Base from '../../layouts/Base.astro';12import LivePreview from '../../components/LivePreview.astro';13import CodeBlock from '../../components/CodeBlock.astro';14import Customizer from '../../components/Customizer.astro';15import SnippetCard from '../../components/SnippetCard.astro';16import { loadSnippets, copyText } from '../../lib/snippets.mjs';1718export async function getStaticPaths() {19 const snippets = await loadSnippets();20 return snippets.map((s) => ({ params: { slug: s.slug }, props: { snippet: s } }));21}2223const { snippet } = Astro.props;24const all = await loadSnippets();25const related = all.filter((s) => s.category === snippet.category && s.slug !== snippet.slug).slice(0, 3);26const initialCopy = copyText(snippet);2728// Everything the client-side customizer needs to rewrite preview + code + clipboard.29const island = {30 header: snippet.header,31 rawCode: snippet.code,32 slug: snippet.slug,33 vars: snippet.customizable.map((c) => ({34 name: c.var,35 unit: c.unit ?? '',36 initial: `${c.default}${c.unit ?? ''}`,37 })),38};39---4041<Base title={`${snippet.title} — SVGarden`} description={snippet.desc}>42 <div class="sg-detail-head">43 <p class="sg-breadcrumb">44 <a href="/">Gallery</a> / <a href={`/category/${snippet.category}`}>{snippet.category}</a> / {snippet.title}45 </p>46 <h1>{snippet.title}</h1>47 <p style="color: var(--sg-text-soft); margin: 0;">{snippet.desc}</p>48 <div class="sg-detail-meta">49 <span class="sg-badge sg-badge-cat">{snippet.category}</span>50 <span class={`sg-badge sg-badge-${snippet.difficulty}`}>{snippet.difficulty}</span>51 {snippet.tags.map((t) => <span class="sg-badge">{t}</span>)}52 </div>53 </div>5455 <div class="sg-detail-grid">56 <div>57 <LivePreview snippet={snippet} variant="full" />58 {59 snippet.support && (60 <p class="sg-support-note">61 <strong>⚠ Browser support</strong>62 <span>{snippet.support}</span>63 </p>64 )65 }66 <aside class="sg-how">67 <h2>How it works</h2>68 <p>{snippet.howItWorks}</p>69 </aside>70 {71 snippet.techniques.length > 0 && (72 <p style="color: var(--sg-text-soft); font-size: 0.875rem;">73 Techniques: {snippet.techniques.join(' · ')}74 </p>75 )76 }77 </div>78 <div>79 {snippet.customizable.length > 0 && <Customizer controls={snippet.customizable} />}80 </div>81 </div>8283 <section class="sg-section">84 <CodeBlock code={initialCopy} label={snippet.relPath} downloadName={`${snippet.slug}.html`} />85 </section>8687 {88 related.length > 0 && (89 <section class="sg-section">90 <h2>More {snippet.category}</h2>91 <div class="sg-related">92 {related.map((s) => (93 <SnippetCard snippet={s} />94 ))}95 </div>96 </section>97 )98 }99100 <script type="application/json" id="sg-island" set:html={JSON.stringify(island)} />101</Base>102103<script>104 // ---- Detail-page wiring: customizer ⇆ preview ⇆ code block ⇆ clipboard ----105 const islandEl = document.getElementById('sg-island');106 const frame = document.getElementById('sg-preview-frame') as HTMLIFrameElement | null;107108 // Preview background toggle (light/dark canvas behind the animation)109 for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-sg-bg]')) {110 btn.addEventListener('click', () => {111 frame?.contentDocument?.body?.style.setProperty('--sg-preview-bg', btn.dataset.sgBg!);112 document113 .querySelectorAll('[data-sg-bg]')114 .forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));115 });116 }117118 if (islandEl) {119 const island = JSON.parse(islandEl.textContent!) as {120 header: string;121 rawCode: string;122 slug: string;123 vars: { name: string; unit: string; initial: string }[];124 };125126 const current = new Map(island.vars.map((v) => [v.name, v.initial]));127 const codeEl = document.querySelector('[data-sg-codeblock] pre code');128 const copySource = document.querySelector('[data-sg-copy-source]');129130 const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');131132 // Rebuild the full copy/download text from the raw snippet + current values.133 const rebuildCopyText = () => {134 let code = island.rawCode;135 for (const [name, value] of current) {136 code = code.replace(new RegExp(`(${esc(name)}\\s*:\\s*)[^;"']+`, 'g'), `$1${value}`);137 }138 if (copySource) copySource.textContent = `${island.header}\n${code}\n`;139 };140141 // Rewrite the highlighted code block in place, keeping Shiki's tokens.142 // Values are declared once, on the root element, so a text-node swap is exact.143 const rewriteCodeDom = (name: string, oldVal: string, newVal: string) => {144 if (!codeEl || oldVal === newVal) return;145 const walker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT);146 const declRe = new RegExp(`(${esc(name)}\\s*:\\s*)${esc(oldVal)}`, 'g');147 let hit = false;148 for (let node = walker.nextNode(); node; node = walker.nextNode()) {149 const text = node.nodeValue ?? '';150 if (declRe.test(text)) {151 node.nodeValue = text.replace(declRe, `$1${newVal}`);152 declRe.lastIndex = 0;153 hit = true;154 }155 }156 if (hit) return;157 // Fallback if the tokenizer split "name:" and "value" apart.158 const walker2 = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT);159 for (let node = walker2.nextNode(); node; node = walker2.nextNode()) {160 const text = node.nodeValue ?? '';161 if (text.includes(oldVal)) {162 node.nodeValue = text.replace(oldVal, newVal);163 return;164 }165 }166 };167168 const applyControl = (input: HTMLInputElement) => {169 const name = input.dataset.var!;170 const value = `${input.value}${input.dataset.unit ?? ''}`;171 const prev = current.get(name)!;172 current.set(name, value);173174 const output = input.closest('.sg-control')?.querySelector('output');175 if (output) output.textContent = value;176177 frame?.contentDocument?.body?.firstElementChild178 ?.getAttribute('style') !== null &&179 (frame?.contentDocument?.body?.firstElementChild as HTMLElement | null)?.style.setProperty(name, value);180181 rewriteCodeDom(name, prev, value);182 rebuildCopyText();183 };184185 const form = document.querySelector<HTMLFormElement>('[data-sg-customizer]');186 form?.addEventListener('input', (e) => {187 if (e.target instanceof HTMLInputElement && e.target.dataset.var) applyControl(e.target);188 });189 form?.addEventListener('reset', () => {190 requestAnimationFrame(() => {191 form.querySelectorAll<HTMLInputElement>('input[data-var]').forEach(applyControl);192 });193 });194 }195</script>196